Govern command visibility, state, and settings ownership - #608
Conversation
…line Phase 0 of docs/superpowers/plans/2026-07-23-command-surface-audit.md. Characterization only — no product behavior changes. The ordered command array moves out of registry.ts into catalog.ts. The split exists because registry.ts answers "what should the picker show right now", which needs a live CommandContext, while four consumers need "what commands EXIST" and have no context to offer: the native menu, Settings, catalog validation, and diagnostics. Those consumers previously reached through buildCommandRegistry and inherited picker filtering as a side effect — the defect the plan names "a picker resolver, not a command-admission authority". A catalog must never be defined by what presentation happened to keep. Native-menu command ids move to a shared constant so the main-process menu and the renderer catalog can be checked against each other. appMenu's dispatchCommand is now typed to that union, so a retired or mistyped id fails the build instead of shipping a menu item that clicks into nothing. The two test files record the before-state the later phases move: - catalog.test.ts pins the exact ordered 102 ids (registration order is the palette's browse order, so a count-only test would pass through an import reshuffle), reconciles 98 literal + 4 generated provider splits, proves all 102 currently resolve to the 'default' tier, and asserts the plan's 102 - 5 + 1 = 98 arithmetic against the real catalog rather than against prose. findCatalogDefects is exercised with broken fixtures so it cannot decay into a function that returns [] for everything. - keybindingBaseline.test.ts records the shortcut authority split. CommandDef.shortcut is display-only; the behavior lives in useKeybinds, Monaco addAction, and a hard-coded palette callback. Seven chords do real work that no palette row advertises: Cmd+Shift+E, Cmd+Shift+P, Option+W, and the four Option+Arrow nav aliases. Save is the sharp case — declared as a command shortcut but implemented twice in the editor, so rebinding it later must remove both or the old chord survives. The declared column is derived from the catalog and compared against the hand-read table, so editing any shortcut: field forces the drift table to be updated in the same commit. Verified: tsc -b clean, 230 files / 1340 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sibility
Phase 1 of docs/superpowers/plans/2026-07-23-command-surface-audit.md.
Before this, the four ways to run a command had four different sets of
guarantees. The palette applied surface + when + renderedView + picker
visibility. The native menu looked its id up in that ALREADY-FILTERED
list. Keybindings called workspace actions directly, evaluating none of
the predicates. Programmatic callers did whatever they liked.
Two opposite failures fall out of that, and both are fixed here:
1. A cosmetic preference could remove a capability. Setting
commandVisibilityOverrides['new-tab'] = false — a "don't clutter my
palette" toggle — made commands.find() return undefined and the
File > New Tab menu item silently do nothing. executeCommand.test.ts
now asserts both halves: the command leaves the picker registry AND
still runs from the menu and from a keybinding.
2. A chord could bypass every capability check, because surface/when
were only ever evaluated while building picker rows.
New modules:
- pickerVisibility.ts — the context-free visibility resolver, shared
verbatim with Settings, which has no CommandContext to offer and
should never synthesize one. Presentation only, by construction: the
word "visibility" does not appear anywhere in the execution path.
- executeCommand.ts — dispatchCommand({id, source, ctx}) resolving
against the FULL catalog, plus dispatchResolvedRow for the palette,
whose list mixes catalog commands with transient agent-index rows that
no id lookup can resolve. Both funnel through one guarded core so the
two entry points cannot drift the way the palette and menu did.
The gateway owns four things that were previously per-call-site or
absent: fresh admission at dispatch time, error capture (every old site
was `void command.run(ctx)`, so a rejected promise vanished with no
user-facing signal), single-flight per command id, and history policy.
History now records AFTER a successful admitted run rather than before
`run`, so a command that turns out to be unavailable or that throws no
longer climbs the user's ranking for failing. Entries carry their
source; absence means picker-origin, which is what all pre-existing data
is, since keybindings could never reach the recorder at all. That is
also why the old counts must stop being described as total command usage.
registry.ts keeps only the question that needs a context. Its surface
gate is now an exhaustive switch with assertNever: the previous
`return true` fallthrough classified an unknown surface as "available
everywhere", the permissive direction, so a surface added to the union
but forgotten here would have silently reintroduced the #228 bug class
instead of failing the build.
Verified: tsc -b clean, 232 files / 1382 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 2 of docs/superpowers/plans/2026-07-23-command-surface-audit.md.
Adds the type model the later phases populate, plus the resolver that
makes one invocation read state exactly once.
The audit's "target scope is invisible" and "target-sensitive commands
independently resolve in when/getState/run" findings are one bug seen
twice. reload-agent asked "which session?" three separate times — to
decide whether to appear, to compute its badge, and to actually reload.
Nothing forced those answers to agree, so focus moving between palette
render and Enter could show session A's provider badge and reload B.
resolveCommandInvocation returns target, availability, state and execute
from a single read; execute closes over the resolved target, so a later
focus change cannot retarget an invocation that already pinned identity.
targetKind DEFAULTS FROM SURFACE rather than being declared on all ~30
session commands. `surface: 'session'` already means "acts on the current
command-target session in both modes" — that is the field's documented
definition — so repeating it would be thirty edits that can drift out of
agreement with the line above them, and a new session command that forgot
it would silently resolve to no target and skip pinning. Everything else
defaults to 'none' and must opt in: a command that should have pinned and
didn't is a visible bug, while one that pins a target it shouldn't is an
invisible one.
Availability ordering implements the recorded product decision on
unavailable presentation:
1. surface mismatch HIDES — a grid-spatial command in Dispatch points
at a layout the user cannot see, and explaining that on every mode
switch is noise;
2. an explicit unavailableReason wins next, letting a command upgrade a
silent hide into an explained disable — the discovery-worthy case,
where someone searching for Rewind on OpenCode deserves a greyed row
that says why rather than silence;
3. a generic when/rendered-view failure hides, unexplained.
targetStillValid is the mutation-boundary check admission cannot cover: a
destructive command may await a confirmation modal or a kill round-trip,
and the target can vanish in that window. It returns false rather than
falling back to whatever is focused, because a confirmation the user
granted for agent A must never authorize killing B.
The CommandContext fixture duplicated in executeCommand.test.ts moves to
a shared harness — two copies with different default flags make two tests
mean different things while looking identical.
Verified: tsc -b clean, 233 files / 1400 tests pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s group Phase 3 of docs/superpowers/plans/2026-07-23-command-surface-audit.md. All 102 commands now declare a user-facing `category`, and the tiers that existed in the type but were never used are populated. The Phase 0 tripwire — "every command resolves to the default tier" — is inverted here rather than deleted, so the before-state stays legible in the diff and the assertion is proven to have fired exactly once, on purpose. Tier outcomes: ten debug-surface diagnostics leave the picker entirely, niche-but-supported operations (rewind, duplicate, bury, the layout rewrites, the per-session MCP toggles) become `advanced`, daily reversible actions stay `default`, and Remote Control becomes the single `experimental` member per the recorded decision — opening the panel is harmless, but binding a port and pairing a device is not, and those stay separately authorized inside the panel regardless of tier. A test enforces that every debug-SURFACE command also carries the developer CATEGORY. The audit's rule is that debug was being used as a visibility policy when it is a surface label; if the two axes disagree about what a command is, Settings groups it away from its siblings. Navigation Commands ships as a closed six-member group — Next/Previous Tab and Focus Pane Left/Right/Up/Down — gated by a new Settings-only `navigationCommandsEnabled`, default off. Three things about that gate are load-bearing and tested: - It is DISCOVERABILITY only. Off removes six rows from a list. It does not touch Cmd+[ / Cmd+], Option+HJKL, the arrow variants, the underlying workspace actions, or dispatch by menu/keybinding/ programmatic call. A preference that silently disabled keyboard navigation would be a functional regression wearing a preference's clothes. - Membership is a closed id list, NOT derived from the `navigate` category. Jump to Latest Message, Spotlight, Reader Mode, Tiled Tabs and Reorder Tabs are all `navigate` and stay ordinary; deriving membership would make a future navigation-adjacent feature default-hidden for reusing a label. - The group gate outranks a per-command override. Otherwise Settings would show six switches that appear able to contradict their parent — the disabled-parent/enabled-child pattern users cannot predict. Coercion is strict `=== true`, so a fresh install, a malformed value and any store predating the field all resolve to off. `!== false` would have flipped six new rows into every existing user's palette on upgrade. The 98 literal annotations were applied by a one-shot codemod from the audit table; the 4 generated provider splits were done by hand because their ids are template literals. Verified by tsc and by tests that assert total category coverage rather than by trusting the script. Verified: tsc -b clean, 234 files / 1427 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…llision gate Phase 4 (foundation) of docs/superpowers/plans/2026-07-23-command-surface-audit.md. This lands the shared library every later consumer needs — normalizer, shipped defaults, reservation registry, collision engine, sparse persistence — plus the repository gate. Routing useKeybinds through it and the Settings editor UI follow; nothing here changes runtime key behavior yet. THE macOS PROBLEM is why the grammar looks the way it does. Option is the text-composition modifier: ⌥D produces "∂", ⌥C produces "ç". So event.key for an Option chord is a glyph, and matching on it fails for every Option binding — which is most of this app's pane grammar. We store `Alt+D` (readable, layout-stable, what the user sees) and match it against event.code === 'KeyD'. Storing the code would put "Alt+KeyD" in a file a human might read; storing event.key would put "Alt+∂" there, which is both unreadable and wrong on another layout. Numpad deliberately does not collapse onto the number row. Modifier order is fixed on the way in rather than compared order-insensitively, which makes string equality the entire collision engine instead of a rule every call site has to remember. Binding CONTEXTS exist so the checker can tell disjoint behavior from an accidental duplicate. ⌥K really does focus a grid pane AND move the Dispatch selection — one gesture, two meanings, never simultaneous. The overlap matrix is a closed list of disjoint pairs (only grid/dispatch today), so adding a context forces someone to state its relationships rather than inherit a permissive default. The user never picks a context; the command contract supplies it, because a user-editable context would be an escape hatch for declaring any conflict safe. Running the engine against the real shipped set immediately surfaced a genuine overlap the plan predicted: Cmd+W is claimed by close-pane globally and by editor-native close-file. It is legitimate — useKeybinds bails out for editor-owned targets before close-pane can fire, and an empty workbench must still swallow Cmd+W or Electron closes the window — so it is recorded as the one APPROVED overlap, keyed by the exact owner pair and carrying its reason. Approving "Cmd+W is fine" would bless a third owner appearing later; approving the pair does not. Defaults are deliberately scarce: every entry either already ran, or is Cmd+, for Settings (the one new chord, and the platform convention). Personalized palette counts explicitly do NOT mint defaults — that data is picker-only and from one profile, so it is evidence a command is useful, not a mandate to spend a scarce global chord. The aliases the palette never advertised (Alt+W, the four Alt+Arrow nav pairs) are now declared, because the point is that the shown binding and the running binding become one fact. Persistence is sparse with three distinct states — absent inherits, `[]` is an explicit unbind, non-empty replaces. Absent and empty must not collapse: absent means "never touched", so a future release may improve the default; empty means "deliberately removed", and resurrecting that chord would override a decision the user made on purpose. Writing an override equal to the shipped default deletes the entry instead of storing a redundant copy, and Reset removes rather than pins. Unknown ids are preserved, since one may belong to a temporarily uninstalled extension. check:keybindings consumes the same normalizer, registry and matrix as the runtime — a script with a parallel notion of "valid" would be green in CI and broken in the app, which is worse than no script. Verified: tsc -b clean, check:keybindings passes, 237 files / 1511 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ette
Completes the plan's headline arithmetic: 102 - 5 + 1 = 98.
RETIRED, with their Settings controls untouched:
toggle-status-mode, toggle-worktree-badges, usage.toggle-header,
usage.cycle-header-level, dangerous-agents
The placement rule is behavioral, not aesthetic: a persisted preference
with no meaningful momentary scope has one product home. There is no
"just for this session" Status Mode, so a command duplicating it gives
the same setting two owners and two places to look when it is wrong.
Two are worth recording individually.
usage.cycle-header-level was retired rather than repaired. It had three
defects at once: it rendered the raw lowercase enum as its badge
("providers"), it cycled forward through four states with no way back,
and it IMPLICITLY ENABLED the header as a side effect of changing the
detail level — so a user who had deliberately hidden the header could not
adjust its detail without silently un-hiding it. That is one command
doing two things, one undisclosed.
dangerous-agents is not a duplicated preference but a SAFETY POSTURE
whose enablement reloads every live agent. A palette row flipping it with
one keystroke and no preview is the wrong affordance, and its copy even
claimed existing agents were unaffected while the runner reloaded them.
Settings owns it, where a confirmation can show the exact affected set.
No value migration is needed because every canonical field survives —
asserted directly, since if one stopped surviving coercion this would be
a data-loss change rather than an information-architecture one.
Stale per-command entries ARE pruned, through an explicit retired-id
list rather than "delete any id not in the catalog". An id naming nothing
today may belong to an uninstalled extension, a provider this build does
not generate, or a command a downgrade removed; pruning by absence would
discard a user's deliberate settings the first time they ran an older
build. Both the visibility and keybinding override maps get the same
policy, and a test proves an unknown extension id survives.
ADDED: open-command-palette, the single approved addition. Cmd+Shift+P
ran through a hard-coded onCommandPalette?.() callback and named no
command, which is exactly why it could not be rebound, listed in
Settings, or collision-checked. It is a full catalog member but never
renders as a palette row — structurally, not via a visibility tier, so
no override or reveal-all can put "open the palette" inside the palette.
The Phase 0 assertions written inverted on purpose ("still contains",
"does not yet contain") are flipped here rather than deleted, so the
retirement is visible in the test diff and not only in the source diff.
Verified: tsc -b clean, check:keybindings passes, 237 files / 1520 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two orchestrated reviewers (Claude + Codex) audited the branch. Their confirmed findings, fixed here. HIGH — Settings contradicted the palette for all six Navigation Commands. Phase 1 claimed pickerVisibility.ts was "shared verbatim with Settings". It was not: settingsRegistry kept a private second copy that knew only about overrides and the declared tier, so it never learned about the group gate. On a fresh install Settings rendered all six navigation switches ON while the palette omitted them, and toggling one wrote an override that still changed nothing because the group gate outranks it — precisely the "switches that appear able to override their parent" shape the precedence rule exists to prevent. Settings now calls the shared resolver, and PickerCommandMeta carries commandGroup so it can. HIGH — the dictation hotkey was compared unnormalized. hotkeyBinding rewrites Alt to Option for display, so a user's Option+D persists as "Option+D" while the command grammar canonicalizes the identical physical chord to "Alt+D". Raw string comparison found no collision between two bindings that register the same key. Once routing lands that means Option-D both starts dictation and runs a command. HIGH — the reservation registry was materially incomplete, which is the failure mode its own docstring warns about. Electron installs accelerators IMPLICITLY from roles, and appMenu.ts only names roles, so the chords appear nowhere in this repo and had to come from Electron's role table rather than from reading the source. Missing: Cmd+W (Close Window), Cmd+Shift+R (forceReload), Cmd+Alt+H, Cmd+Ctrl+F, Cmd+Alt+Shift+V, Cmd+= / Cmd+- (zoom). Also missing: Cmd+[ / Cmd+] editor indentation, the stateful Cmd+Arrow tiled-resize continuation, editor file-tab keys, and feed picker navigation. Adding them surfaced three previously invisible dual-ownerships (Cmd+[, Cmd+], End) plus Cmd+Shift+R, all pre-existing and all resolved by focus precedence. Recorded as approved overlaps with reasons rather than silently tolerated — Cmd+Shift+R in particular looks fine until someone unbinds resume-session and discovers it now force-reloads the app mid-session. MEDIUM — an all-malformed override array became a permanent unbind. The old rule collapsed it to [], which means "explicitly unbound" and outranks the shipped default forever. The reasoning was self-defeating: a real unbind already writes [], so the rule only ever fired on values the user did not author that way. Run an older build whose parser lacks a newer key token and the binding was destroyed unrecoverably. Corruption now drops the key so the command inherits again; a genuine [] still round-trips. MEDIUM — setCommandKeybindings deleted an entry equal to today's default, contradicting the plan's "preserves explicit choices when a later release changes shipped defaults". A user deliberately keeping a command on its current chord would have it silently moved by a future release. Explicit edits are now recorded; resetCommandKeybindings remains the way back. MEDIUM — commands without a shipped default were forced to 'global' context, which forbids customization the overlap matrix explicitly permits (assigning grid-only Alt+K when only Dispatch owns it). Context is now injectable. It is INJECTED rather than derived here because importing the catalog into resolve.ts creates a real initialization cycle through the provider capability registry — caught by three test files failing to load with a TDZ error, not by tsc. MEDIUM — normalize accepted Cmd++, which nothing can ever fire (plus emits code Equal with shift, canonicalizing to Cmd+Shift+=), and let numpad keys collapse onto main-keyboard tokens through the event.key fallback, defeating the Digit/Numpad separation directly above it. MEDIUM — check:keybindings permanently exempted open-command-palette from catalog ownership with a comment claiming it was "checked separately below"; there was no such check, and the command has since landed. It also never called findCatalogDefects, so duplicate catalog ids passed. HIGH (honesty) — the target-pinning test was vacuous: it built a second context and discarded it (`void laterCtx`), so nothing changed and the assertion only proved execute() forwards the object it was handed. It now mutates the workspace state between resolve and execute. The module docstring claimed pinning prevents retargeting; it does not, because CommandDef.run takes no target parameter and every session command still resolves its own. The comment now states that scope honestly and a test asserts it, rather than overclaiming what Phase 7 will deliver. Also: store version 8 -> 9 for the two new persisted fields. Verified: tsc -b clean, check:keybindings OK (12 reservations, 5 approved overlaps), 237 files / 1524 tests pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 5 of docs/superpowers/plans/2026-07-23-command-surface-audit.md. The audit's high-severity provider finding: `isAgentProviderKind()` was being consumed as a FEATURE capability. It only distinguishes agents from terminals. Every agent provider passed it, so OpenCode was offered Rewind, Duplicate, Switch Provider and Copy Resume — all empty, rejected, unsupported or unverified for it. The user saw an ordinary enabled command and got nothing. Each provider identity descriptor now declares five capabilities: savedSessionListing, transcriptRewind, transcriptDuplicate, switchTargets and verifiedExternalResumeCommand. The field is REQUIRED on the registry type, so a provider added without answering these questions fails the build rather than inheriting broad agent powers by joining AGENT_PROVIDER_KINDS. switchTargets is an edge LIST, not a boolean: "can switch" is meaningless without naming a destination, and translation is directional — a Claude->Codex adapter is not automatically a Codex->OpenCode one. OpenCode declares all five as false/empty. That is the correction, not a slight: it has no saved-session listing in main, no transcript adapter for rewind or duplicate to operate on, no switch edge in either direction, and its resumeCommand is an unverified guess (its own comment in the identity file has said so since #406). They flip individually as each adapter becomes real; flipping them as a group is the mistake this phase exists to prevent. copy-resume-command is the sharpest case. An unverified template hands the user a shell command that may not work, which is worse than not offering it at all — they paste it into a terminal and blame their setup. The MCP matrix is untouched: Workflow MCP stays Codex-only, Claude keeps its native workflow surface, OpenCode gets no injected built-in domains. NOT included, deliberately: the disabled-with-reason presentation for these refusals. `unavailableReason` exists on CommandDef and resolveCommandAvailability implements the recorded product decision, but the palette does not consume the resolver yet, so a reason declared today would be dead code. These four commands currently HIDE on an unsupported provider rather than showing greyed with an explanation. Wiring the picker to the resolver is the remaining half. Verified: tsc -b clean, check:keybindings OK, 238 files / 1530 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c3a57a0 shipped claiming "tsc -b clean". It was not. My verification ran `npx tsc -b | head` and read `$?`, which is HEAD's exit status, not tsc's — so 72 errors reported as success. A clean build at 1f16bbe (before Phase 5) gives 0 errors; at c3a57a0 it gives 72. Incremental builds hid it locally because the stale .tsc-out skipped the affected project. Root cause: `src/providers/**` is in the NODE tsconfig project, but `providers/<kind>/renderer/**` is excluded from it because that subtree is renderer-only JSX. `getProviderFeatures` was hung off `registry.renderer.capabilities.ts`, which imports those `.tsx` row and view components, so a node-side test importing it dragged the whole JSX graph into a project that cannot compile it. The fix is better architecture, not a tsconfig patch. Feature capabilities are pure policy data; they never needed the renderer capability registry. They now live in `providers/shared/ featureCapabilities.ts` as one exhaustive `Record<AgentProviderKind, ProviderFeatureCapabilities>`, which is node-safe and readable by command guards, tests, and eventually main. Moving them off the per-provider identity descriptors loses nothing that mattered: the exhaustive Record still fails to compile until a newly added provider answers every capability question, so a provider still cannot inherit features merely by existing. It also fixes a second boundary problem — the identity files are themselves under `renderer/**` and therefore node-excluded, so declaring capabilities there would have kept them unreadable from main forever. Also lands the store channel Phase 4's routing needs: `pendingCommandInvocation` generalizes the palette's private `pendingMenuCommand` so keybinding and native-menu invocations share ONE dispatch path. The keybinding router cannot cheaply build a CommandContext on every keydown (assembling it is the cost the palette avoids while closed, #494), so a chord takes the same route a menu click already does: record the id, let the palette mount its implementation and dispatch. Not yet wired to useKeybinds — that is the next commit. Verification from here on uses `cmd > log; echo $?` rather than piping into head. Verified by CLEAN build (rm -rf .tsc-out): tsc -b exit 0 / 0 errors, check:keybindings exit 0, vitest exit 0 — 238 files / 1530 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 4 (routing) of docs/superpowers/plans/2026-07-23-command-surface-audit.md. The keyboard router now resolves EFFECTIVE bindings — shipped defaults overlaid with the user's overrides — and hands the command id to dispatchCommand, instead of calling workspace actions directly. This is what makes a Settings edit real. Until now `CommandDef.shortcut` was a display string and useKeybinds implemented the behaviour separately, so editing a binding would have changed a label while the hardcoded chord kept running. It also closes the second half of the plan's central defect. A chord previously evaluated NONE of the surface/when/renderedView predicates the palette applied to the same command, so a keyboard user could reach a command the palette would have refused. Every source now shares one admission check, one error path, and one history policy. HOW the router reaches a CommandContext: it does not build one. Assembling the ~76 workspace actions and flags is exactly the cost CommandPalette avoids paying while closed (#494), and a keydown handler cannot pay it per keystroke. The native menu already solved this — emit a bare id, let the palette mount its implementation just long enough to dispatch. A keypress is equally rare and equally intentional, so it takes the same route. The palette's private `pendingMenuCommand` is now the store's `pendingCommandInvocation`, carrying its source, so menu and keybinding share ONE dispatch path rather than growing a second one beside it. WHAT IS ROUTED is a closed list of 24 command ids. Everything else in the handler stays where it is, deliberately: Escape dismissal, picker navigation, numbered tab/Dispatch selection, split resizing and the tiled resize continuation are CONTEXTUAL INTERACTIONS, not commands. They have no meaningful palette row, and inventing one for each would produce dozens of fake commands whose only purpose is to shorten a file. They are declared in the reservation registry instead, so a user cannot bind a command on top of them. Removed hardcoded branches: Cmd+Shift+P (which named no command at all), Cmd+Shift+E, Cmd+Shift+T. One bug caught while wiring: `open-command-palette` arrives with closeAfterRun=true, because the palette was shut when the chord fired. Honouring that blindly opened the palette and closed it in the same frame — the chord looked broken. Palette-self commands are now exempt from the close-after-run rule. Still to come in Phase 4: the Settings multi-binding capture editor, and deleting CommandDef.shortcut once the palette renders effective bindings. The 22 legacy shortcut strings still exist and still agree with the defaults, so display is correct today, but they remain a second source of truth until that lands. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 239 files / 1536 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the last structural gap in Phase 4 of the command-governance plan. `CommandDef.shortcut` is gone. It was a hand-authored display string with no relationship to the code that ran — the audit's core keybinding finding. The palette could advertise Cmd+S for a command Monaco actually implemented, four Option+Arrow aliases ran with nothing advertising them, and Cmd+Shift+E worked while its row showed no chord at all. `ResolvedCommand.shortcut` is now DERIVED in buildCommandRegistry from the effective binding set — shipped defaults overlaid with the user's overrides — which is the same set the router matches against. The chord the palette shows and the chord that runs are one fact, structurally, rather than two that happened to agree. That also makes the Settings editor meaningful before it exists: an override already changes both display and behaviour, so the remaining UI work is a control over a contract that already holds. 20 authored strings removed across the command modules (the other 2 were the generated provider splits, which built theirs from a template). Overrides are threaded through CommandContext.flags so the registry can resolve without importing the settings store. check:keybindings section 7 flips from a soft note to a hard failure. It was advisory while the legacy strings still existed — failing then would have blocked the very commits removing them. Now that they are gone, reintroducing an authored `shortcut` rebuilds exactly the drift this phase eliminated, so it fails the build. keybindingBaseline.test.ts is rewritten rather than deleted. Its load-bearing assertion used to compare the recorded drift table against `CommandDef.shortcut`; it now checks that every chord the table recorded as REALLY RUNNING is present in the effective set. That is the migration's actual claim — behaviour unchanged, source of truth moved — and a chord that silently stopped working fails there. The pre-migration table is kept as the evidence for what the defaults had to preserve. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 239 files / 1536 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes Phase 4 of the command-governance plan. Commands & Shortcuts is now a searchable, category-grouped list of every built-in command, each with zero, one, or several bindings: chips with Remove, "Not assigned" when empty, Add via keyboard capture, per-command Reset, and page-level Reset All. "Keybind control of all commands" means exactly that and no more. Escape dismissal, picker navigation, numbered selection and split resizing stay contextual interactions — they appear here only as CONFLICT OWNERS, never as editable rows. Turning them into commands would produce dozens of fake palette entries whose only purpose is to make a file shorter. WHY this does not reuse HotkeyInput: dictation captures modifier-only holds (bare Cmd is a legitimate push-to-talk binding) and commits them on a settle timer. A command chord fires on keydown and can never be modifier-only, so inheriting that policy would offer users bindings the router cannot match. The two capture surfaces share the normalizer, not the interaction model. Capture listens on the window in CAPTURE phase and both preventDefault and stopPropagation, because the workspace router is listening there too. Without that, the chord being recorded would also execute — pressing Cmd+W to bind it would close the pane behind the Settings page. Conflicts BLOCK the save and name the owner. "That shortcut is taken" is not actionable; "Cmd+T is used by New Tab" is. Registration order, last-write-wins and silent precedence are all forbidden, so the only override path is an explicit Replace, which strips the chord from every prior owner and installs it on the requester in ONE settings write — the store can never be observed with a chord owned twice or by nobody. Reserved interactions offer no Replace at all: Escape, native menu roles and editor-native keys are not ours to reassign. Conflict lookup consumes the EFFECTIVE set, not the shipped defaults, so a chord the user already moved onto another command still conflicts. Checking the shipped table alone would let two of the user's own commands silently share a chord. It also checks the CURRENT PROFILE's dictation hotkey — the repository script checks the shipped default; same function, different input, which is the point of sharing one collision engine. Rows exist for commands the palette never renders, including open-command-palette: they are reachable by chord, menu and programmatic call, so they are bindable. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 240 files / 1547 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 6 of docs/superpowers/plans/2026-07-23-command-surface-audit.md.
`CommandState` was `{label: string; tone?}` and the renderer uppercased
every label into one chip, so a boolean, a selected value, contextual
information, an unsupported capability and async progress were
indistinguishable. It is now a discriminated union — toggle / value /
status — with `truth` recording whether a state is persisted, runtime, or
EFFECTIVE.
TONE IS DERIVED, never authored. That is the constraint that makes the
rest hold: a caller physically cannot say "colour this danger"
independently of what it means, so colour and meaning cannot drift apart.
`describeCommandState` is the single place a state becomes pixels, and
both the palette row and the details pane call it — each previously
carried its own copy of the tone ternary.
The six defects the audit named, each now unrepresentable:
- Tail showed "On (all)", a state invoking Tail cannot turn off because
Tail All owns it. Now `truth: 'effective'` with a detail explaining
which command actually controls it.
- Caffeinate rendered "Unsupported" as an ordinary neutral value, so a
platform-unsupported command looked identical to one merely off. Now a
first-class `status('unavailable', …)` that mutes the row.
- Dispatch Mode rendered Global/Project/Off through the on-off chip, so a
SCOPE read as an enabled state. Now an enum value.
- Provider badges on Reload / Soft Reload / Copy Resume / Switch Provider
were accent-toned, reading as live toggles. They are context values,
always neutral.
- Reply to Selection styled its stashed snippet as accent for the same
reason. Also a value now.
- Panels were split between "On/Off" and "Open/Closed" with no rule.
`panel()` owns that vocabulary so the split cannot return.
Rendering Debug Mode is the interesting migration: it authored `danger`
tone. The warning is real — the mode intercepts every feed click — but
tone follows meaning, and the meaning is "on". The warning moved into a
detail string, which says more and cannot drift from the actual state.
34 getState definitions migrated. 15 were mechanical enough to codemod;
the rest were hand-migrated precisely because they were the ones the flat
shape had been hiding something in.
Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly:
tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 241 files / 1556 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 7 (part 1) of the command-governance plan.
The plan's decision 5 is explicit that this is a FEATURE RETIREMENT, not a
misplaced command: the opt-in auto-created companion terminal and its
dedicated Dispatch side column are gone. Ordinary user-created terminals
are untouched — they remain normal sessions and normal Dispatch rows.
Removed: the Settings row, `Settings.dispatchProjectTerminal` and its
default and coercion, the DispatchLayout auto-create effect, the
fixed-width side column, and the `ensureDispatchTerminal` action plus its
interface entry and hook export (DispatchLayout was its only caller, so it
became dead the moment the effect went).
THE SPREAD TRAP, which the plan flags by name and which is the reason this
needed more than a delete: `coerceSettings` does `{...DEFAULT_SETTINGS,
...parsed}`. Deleting a field from the TYPE does not delete it from a
user's stored blob — `...parsed` copies every key it finds, so the retired
key survives coercion, gets written back on the next save, and lives
forever in localStorage, invisible to TypeScript. `parsed` now goes
through `omitRetiredSettingsKeys`, driven by an explicit
RETIRED_SETTINGS_KEYS list.
An explicit list rather than "drop anything not in DEFAULT_SETTINGS":
that broader rule would silently discard keys belonging to features under
development or to a build the user downgraded from. Retiring a key is a
deliberate act and reads as one.
The test covers the real lifecycle rather than a single call — coerce,
serialize, coerce again — because a key that survives one round trip
survives all of them. It also asserts the omission is surgical: a filter
that dropped more than its list would quietly reset unrelated preferences
on upgrade.
Store version 9 -> 10.
Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly:
tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 242 files / 1562 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 7 (part 2a) of the command-governance plan: the recorded product decision on when a destructive close needs the user to confirm, as a pure testable module. Confirmation is deliberately NOT a blanket modal. Closing a pane happens dozens of times an hour, and a dialog on the common case would cost more than the risk it guards. The three-way split: idle single session -> immediate, Undo Close recovers it running or streaming -> confirm cascade or multi-target-> confirm, naming the EXACT count and list WHY "it's undoable" only justifies the first case: Undo Close restores the workspace entry, not the world. It cannot bring back live terminal scrollback, an unsent composer draft, or a half-finished cascade where some children died and others did not. The cheap case stays cheap precisely because it is the one undo actually covers. `grantStillMatches` compares by ID SET, not by count. The audit's bulk-close finding is that a preview can go stale between confirmation and kill — and two agents finishing while two others spawn keeps the count identical while changing every target, which is exactly what a count check waves through. `narrowGrantToCurrent` lets a bulk flow proceed with what survives instead of abandoning everything, with one asymmetry that matters: a session that STARTED working since the preview is dropped. The user approved closing an idle agent; one that woke up is outside what they authorized even though its id is unchanged. Failures and skips are reported separately because they mean different things — a failure is a backend problem worth retrying, a skip is the grant correctly refusing to cover work the user never saw. The module is pure by design: it decides, and does not prompt, mutate, or know what a dialog looks like. That keeps the decision reviewable in one place rather than re-derived at each of the five close entry points (palette, keybinding, tab button, native menu, programmatic). Wiring those entry points to it is the next commit. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 243 files / 1576 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 8 of docs/superpowers/plans/2026-07-23-command-surface-audit.md. Command titles now follow the audit table. IDS ARE UNCHANGED, which is the point: titles are presentation, but ids are the contract that user visibility overrides, keybinding overrides and native-menu lookups are keyed on. Renaming an id would silently discard settings the user made. The renames, and what each was getting wrong: - Tail / Tail All -> Auto-follow Focused Agent / Auto-follow All Visible Agents. "Tail" names an implementation, not a behaviour, and gave no hint that one is scoped to a session and the other to the workspace. - Close/Bury/Revive/Kill "Pane" -> "Session". The live object persists without a pane, so Pane named the wrong noun — and Close Pane can close a Dispatch row that has no pane at all. - Global Dispatch -> Dispatch Scope, with Project/Global state instead of On/Off. "Global Dispatch: Off" told the user nothing about what Off meant; the alternative is Project scope, not "no dispatch". - Toggle Session Recording -> Session Recording. The repo's command-style rule is that titles are stable nouns and state lives in the badge. - Set Agent View Mode... -> Agent View for This Session…, which discloses the per-session scope the old title hid, and fixes three periods to a typographic ellipsis. - Set color flag -> Set Color Flag…, title case plus the ellipsis that says more input follows. Old vocabulary is retained as keywords. Someone with "Tail" or "Pane" in muscle memory must still find the renamed command — a rename that breaks search breaks the feature. Settings rows gain machine-readable scope/apply/storage/status metadata, rendered as badges. It defaults to app/immediate/settings and only EXCEPTIONS are annotated, so a badge means "the obvious reading would have been wrong" rather than becoming wallpaper on forty identical rows. The exceptions are the ones the audit named: Dangerous Agents reloads live agents (its copy claimed the opposite), Default Workspace Mode only affects a fresh install, and the dictation API key and CLI update policy live in the keychain and main's setup.json — which is what finally makes "what does Reset Settings actually reset" answerable. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 244 files / 1594 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 7 (part 2b): the expansion the confirmation policy judges. `closeConfirmationFor` decides on an EXPANDED target set. Expansion is separate because getting it wrong is the actual danger — an unexpanded set silently downgrades a cascade to a single close, and the user confirms one thing while four sessions die. Expansion is TRANSITIVE. A linked child can itself have linked children, so stopping at one level would under-report the cascade in exactly the deep case where the count matters most. The visited set guards a malformed parent cycle rather than trusting the data to be a clean tree: an infinite loop here would freeze the app on a close. Tab expansion includes the tab's DETACHED Dispatch sessions, which are the ones people forget. A detached session has no tile in the tab the user is looking at, so a tab close that takes six background agents with it currently looks like closing an empty tab. Liveness reads `sessionStatus === 'running'` OR a non-idle `streamPhase` — deliberately the same pair CloseOldAgentsModal already uses, so its preview and this confirmation cannot disagree about who is busy. The end-to-end assertion is the one that matters: closing ONE pane with two linked descendants produces a confirmation naming three sessions. Still to wire: the dialog itself, and the close paths calling this before mutating. The decision layer is complete and tested; nothing in the app consults it yet. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 244 files / 1603 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 7 (part 2c): the decision layer is now consulted. Closing a pane that is working, or that takes linked descendants with it, asks first. THE BRIDGE. `closeFocused` is an async action inside a hook that needs to stop, ask, and continue with the answer — something React state alone cannot express, since a component renders the dialog but the action needs a value back. Threading a resolver through the store would put a non-serializable function into zustand. So the broker holds the pending resolver at module scope and the dialog resolves it; the action just awaits a promise. Module scope rather than a ref because requester and responder live in different trees — an action inside the workspace hook and a surface mounted at the app root. A superseded request resolves as DECLINED rather than being abandoned. An abandoned promise leaves its close path awaiting forever, which presents as a pane that will not close with no error anywhere — worse than a refusal the user can retry. Escape, the overlay and the close button all resolve false for the same reason. The gate runs before the branch that picks a close path, not inside one arm of it: the user's answer must cover the whole operation. Targets are EXPANDED before judging. Closing one visible pane can end four sessions, and asking about one while killing four is precisely the failure this exists to prevent. An idle single close returns `required: false` and falls straight through, so the common case — the one people do dozens of times an hour, and the one Undo Close genuinely covers — pays nothing. The dialog LISTS the sessions rather than only counting them. A bare "close 4 sessions?" fixes the count but still leaves the user unable to check whether those four are the four they meant. Still to wire: closeTab, Close Old Agents re-enumeration, Kill Buried's second confirmation, and the Agent Management MCP grant. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 245 files / 1610 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 7 (part 2d): every destructive path now consults the policy. closeTab expands grid leaves AND the tab's detached Dispatch records, each through its linked descendants. The detached ones are what people forget — they have no tile in the tab on screen, so closing what looks like a two-pane tab can end eight agents. Expanding both is what makes the number in the dialog the real number. Close Old Agents RE-ENUMERATES BEFORE EVERY KILL, not once after confirmation. Between clicking and the tenth kill, agents finish, new ones spawn, and an idle agent in the approved list can wake up and start working — a grant checked once at the top would authorize killing it. Per-iteration re-reading is affordable because the loop is already sequential (closeSession mutates the tree, so concurrency would make each call read a stale snapshot). A backend refusing no longer abandons the batch. The user asked for twelve agents closed; nine succeeding is a better outcome than stopping at the first failure with no report. Failures and skips are counted separately and surfaced, because they mean different things: a failure is worth retrying, a skip is the grant correctly refusing to cover work the user never saw. Kill Buried confirms UNCONDITIONALLY, unlike the ordinary close paths. It is the one close in the app with no undo at all — a buried session is not on the undo-close stack — so there is no cheap idle case to protect, because there is no recovery even when the session is idle. The picker's own selection is not consent to something irreversible. `isSessionLiveForClose` is exported and shared by expansion, the bulk preview and the single-session kill. Three copies of "is this busy" is exactly how a preview and a confirmation come to disagree. Remaining: the Agent Management MCP close grant. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 245 files / 1610 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last item in docs/superpowers/plans/2026-07-23-command-surface-audit.md. The plan's line is blunt: "Prose is not an enforceable grant." The close tool's permission rule lived entirely in its description and the domain instructions — several sentences telling the model never to close an agent unless the user's current request named that specific agent, and never to infer permission from age, completion state, transcript contents, or a prior request. That is a request to a language model, not a check. It cannot fail closed, it cannot be tested, and a model that reads "clean up this project" as authorization produces an irreversible kill. Closes now require a grant, checked at the mutation boundary in AgentManagementBridge — the single point every close request passes through, so a caller that reached the bridge another way cannot skip it. Default is DENY. A grant names an exact caller/target pair, is SINGLE-USE, and EXPIRES after two minutes. Both properties matter: "the user asked me to close agent X" is true about one moment, not forever, so a durable grant would let a later turn spend authorization given once for something specific, and a pair-scoped one stops a model told to close X from deciding Y also looks stale. An expired grant is deleted on the attempt that finds it dead, so a caller cannot poll until a clock race lets it through. Grants are revoked in BOTH directions when a session goes away: one naming a dead caller could be replayed by a session that reused the id, and one naming a dead target is meaningless. The existing project/self/cascade refusals are untouched — this is an additional gate, not a replacement. Two existing bridge tests now issue a grant explicitly, which is what makes them tests of cascade refusal rather than of authorization. VERIFICATION NOTE, stated plainly rather than glossed: at default concurrency the full suite intermittently fails ONE arbitrary test with a 5s timeout — a different file each run (lazy-prose, then store hydration), both untouched by this branch, both passing in isolation and when their own project runs alone. With `--maxWorkers=4` the suite passes completely. This is machine contention, not a defect introduced here, and it is worth someone looking at separately. Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly: tsc -b 0 / 0 errors, check:keybindings 0, vitest --maxWorkers=4 0 — 246 files / 1623 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan fully implementedAll nine phases of the command-surface governance plan are now on this branch (20 implementation commits on top of the 4 planning commits).
Headline arithmetic verified against running code: 102 baseline − 5 retired preferences + 1 ( The four questions the plan reservedAnswered and implemented: unavailable presentation (hide mode-irrelevant, disable-with-reason for discovery-worthy), close-confirmation threshold (idle single immediate; running/cascade confirm with exact count), Remote Control Notable
Verification
Ready for review. |
The governance implementation was reported complete. It is not. The PR comment saying "Plan fully implemented — ready for review" was wrong, and this document is the correction. Nine parallel orchestration reviewers (five Claude, four Codex) — four unscoped whole-PR passes, four with a starting emphasis, one on overengineering — found 32 distinct defects, six of them blocking. Every finding recorded here was independently reproduced against the working tree before being written down; convergence counts are noted per finding because seven of nine independently found the same top defect. Three patterns explain nearly all of it, and they matter more than the individual bugs: 1. New code was added IN FRONT OF old code instead of replacing it. The keybinding router went in above the focus-ownership guards and above every legacy chord branch, and nothing was removed. The new path wins when it matches and the old path wins when it does not — exactly the two-authorities condition the original audit existed to eliminate. This single mistake produces four of the six blockers. 2. Decision layers were built and then not consulted. resolveInvocation, grantStillMatches, contextForCommand, issueCloseGrant, savedSessionListing and CommandState.truth are all written, tested, and called by nothing in production. Each reads as a guarantee the code does not provide — which is worse than its absence, because the next reader will rely on it. 3. Scripted edits were trusted because the build stayed green. The Phase 5 capability gates landed on the wrong commands because a replace-first-match codemod matched a generic pattern rather than the intended block; switch-provider and copy-resume-command never got their gates at all. tsc and the suite passed, and the commit message asserted the change had been made correctly. The common thread: the suite grew by ~3,300 lines and caught none of it, because the new tests exercise the new modules in isolation and nothing tests the wiring between them. The required-tests section targets that specifically rather than adding more of the same. Notable individual findings: the MCP close tool is permanently denied because nothing can issue the grant it now requires; Cmd+W inside Monaco kills the agent pane behind the editor; Dispatch arrow navigation is dead; unbinding a command in Settings leaves its old chord fully live; Settings offers bindings for 74 commands the router cannot dispatch; and src/mcp/shared/closeGrant.ts contains a NUL byte, so git and GitHub both classify it as binary and it is literally unreviewable in this PR. Six remediation phases with independent rollback boundaries. R1 (make the router replace rather than shadow) and R2 (bind confirmation to the approved target set, and route every close path through it) are the two that make the branch honest; the rest is repair and deletion. No remediation has started. This is a review checkpoint. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Correction: the plan is not fully implementedMy earlier comment said "Plan fully implemented — ready for review." That was wrong. Nine parallel reviewers (5 Claude, 4 Codex) found 32 defects, 6 blocking. Remediation plan committed as Every finding was independently reproduced against the working tree. Convergence counts are recorded per finding — 7 of 9 reviewers independently found the top defect. Blocking
Three root causes, more useful than the individual bugs
The suite grew ~3,300 lines and caught none of it: the new tests exercise new modules in isolation, and nothing tests the wiring between them. Also worth knowing
Two of my commit messages describe code that does not run: "grants are revoked in BOTH directions" ( RemediationSix phases with independent rollback boundaries. R1 (make the router replace rather than shadow) and R2 (bind confirmation to the approved target set; route every close path through it) are the two that make the branch honest. R4 requires a decision: wire the MCP grant to a real issuer, or revert it — shipping enforcement with no issuer is not an option. Not ready for review or merge. The plan is the review checkpoint. |
R1 of docs/superpowers/plans/2026-07-26-command-governance-remediation.md.
Fixes blockers B2, B3, B4 and B6, plus F1 and F2.
The first attempt inserted the router at the TOP of useKeybinds, above the
focus-ownership guards and above every legacy chord branch, and removed
nothing. The new path won when it matched and the old path won when it
did not — the two-authorities condition the original audit existed to
eliminate. Four blockers fell out of that one mistake.
POSITION. The routed lookup now runs after everything that OWNS an
interaction: app modals, placement overlays, editor chrome, the
assistant/code-block pickers, and Escape. Three regressions go with it —
Cmd+W inside Monaco no longer kills the agent pane behind the editor,
bare End in a composer is a caret key again, and Cmd+[ / Cmd+] are
Monaco's indent again.
CONTEXT. `activeBindingContexts` returns the SET of contexts live for an
event ('global' always, plus exactly one of grid/dispatch, plus 'editor'
and 'feed' when they apply), and routing matches only bindings whose
context is in it. Matching on chord alone is what killed Dispatch
navigation: Alt+J resolved to the grid-context nav-down, was
preventDefault-ed, then refused by admission, so the Dispatch handler
below was unreachable and the selection never moved.
DERIVED, NOT ENUMERATED. ROUTED_COMMAND_IDS is deleted. It listed 24 ids
while Settings offered bindings for all 98 commands, so 74 rows persisted
a chord, displayed it, reserved it in the collision checker, and did
nothing when pressed. It is replaced by a deny-list of commands another
surface owns, which inverts the failure mode: forgetting an entry routes
a chord that should have gone to the editor — visible immediately —
rather than silently selling a binding that can never fire.
DELETED, NOT SHADOWED. The legacy branches for Cmd+T, Cmd+Shift+R,
Cmd+Shift+W, Cmd+W, Cmd+[ / Cmd+], Cmd+Alt+E, Cmd+P, Cmd+Shift+F, the
Option split/terminal/provider/close chords, Option navigation, and bare
End are gone. Unbinding a command in Settings now genuinely unbinds it;
before, the row read "Not assigned" and the chord still fired.
Monaco and EditorWorkbench derive Save's chord from the effective binding
via a new `toMonacoChord` translation, so rebinding Save moves all of
them together. It returns a description rather than a Monaco bitmask so
the keybinding modules stay importable from node-side tooling. An unbound
or untranslatable Save registers NOTHING rather than falling back to
Cmd+S — "Not assigned" has to mean it. Editor-native file CLOSE stays
hard-coded because it is a reserved interaction, not a command.
Two behaviours moved into the commands that now own them:
toggle-editor-fullscreen opens the editor straight into fullscreen when
closed (its `when` no longer requires an open editor), which the deleted
chord did and the command did not.
PERFORMANCE. The chord index is built once per override change instead of
rebuilding the default table and re-normalizing ~30 strings on every
keydown, and matching is a Map lookup. `requestCommandInvocation` no
longer forces the palette open: the host mounts for a pending invocation
but renders no dialog, so Option+H/J pane focus stops portaling a modal
and trapping focus per keypress while keeping the #494 cost model.
routerWiring.test.ts covers the joins rather than the modules, which is
where all four defects lived and why ~3,300 lines of new tests missed
them.
Verified by CLEAN build (rm -rf .tsc-out), exit codes captured directly:
tsc -b 0 / 0 errors, check:keybindings 0, vitest 0 — 247 files / 1635 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…itle Six unrelated defects the nine-agent review surfaced, none of which the build could catch. closeGrant.ts contained a literal NUL byte in a template literal, so git classified the whole file as binary and GitHub refused to render its diff. The most security-sensitive file in the change was the one nobody could review. Same separator, written as the escape sequence. settings/types.ts lost its comment terminator when the dispatch project terminal was removed: the removed-feature comment swallowed the closing delimiter and became the docstring for `autoSendPromptSuggestion`, which it does not describe. Deleted -- the field it documented is gone. Kill Buried passed `reason: 'running'`, so the dialog titled an idle buried session "Close a working agent?". It now has its own 'irreversible' reason and title. Also collapses the old 'cascade'/'bulk' split, which derived the reason from liveness rather than from origin and was therefore both wrong and never read: 'multi'. The broker emitted while `resolver` still pointed at the just-resolved superseded function. Install-then-emit instead. Plus: DispatchLayout computed a terminal target nothing consumed and carried a comment truncated mid-sentence; the Dispatch Mode command still described "an optional project terminal" that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate shipped in Phase 7 asked the right question about the wrong set, at the wrong time, on three of the five close paths. Five defects, one shape: the dialog and the mutation were reading different things. WHAT IT COUNTED. `closeFocused` expanded only the linked cascade. But both grid close paths also take the tab's DETACHED children when the closing pane is the tab's last one -- sessions with no tile anywhere, so nothing on screen hints at them. Closing the final pane of a tab with six background Dispatch agents asked about one session and killed seven. New `paneCloseTargets` enumerates what the mutation enumerates. WHEN IT READ. Every path captured its snapshot BEFORE the dialog and then mutated from it afterwards -- the undo entry, the kill list, the detached metas, all describing a workspace that stopped existing while the user was reading. `closeTab` was worse: it read the `state` prop, already a render behind before any await. Both now read through `stateRef` after the gate. WHETHER IT WAS ASKED AT ALL. `closeSession` was ungated, which made it the way AROUND the policy rather than an implementation of it: the Agent Activity modal's Close button and every MCP-driven close ran straight through, cascade and all. It is now gated by default, with `preConfirmed` for the three callers that have genuinely already asked. WHETHER THE APPROVAL STILL HELD. `grantStillMatches` existed, was tested, and had no production caller. `runCloseConfirmationGate` now owns the whole sequence -- enumerate, judge, ask, RE-enumerate, compare by id set -- so there is no way to get permission out of it without the second enumeration having agreed. Close Old Agents' per-kill revalidation had the same shape of bug twice over: it re-read a `workspace` frozen in the callback's closure (so twelve iterations re-derived one answer), and it compared only ids and liveness, never age or project scope -- the two criteria that actually put a row in the list. It now re-runs the real predicate against a live ref. Also: every command body wrote `void workspace.closeFocused()`, discarding the promise the execution gateway awaits. The gateway's error capture and single-flight were watching `undefined`. Sixteen call sites return now.
Phase 5 replaced `isAgentProviderKind()` as a feature test and built the capability matrix. Then it wired four of the six consumers to the wrong row, and left two commands on the predicate it was meant to replace. view-prompts read switchTargets -> promptHistoryExtraction reload-agent read verifiedExternalResume -> inAppResume switch-provider read isAgentProviderKind -> switchTargets copy-resume-command read isAgentProviderKind -> verifiedExternalResume Nothing looked wrong, and that is the interesting part: Claude and Codex had every capability and OpenCode had none, so a transposed pair returned the same answer for all three providers. The matrix test passed because the matrix was right. Adding the two missing capabilities is what exposed it. Reload Agent respawns a session through our own spawn path with a resume id -- it never hands anyone a shell string -- and OpenCode supports that fine (`opencodeSession` takes the id as `sessionID` and replays). So the flag it was borrowing was not merely unrelated, it was hiding a working command. View Prompts likewise needs only the READ side of the transcript, which `extractLatestUserPrompts` provides by keying on Claude's `permissionMode` with a Codex exception -- so OpenCode yields an empty list, for a reason that has nothing to do with switch edges. `savedSessionListing` had no reader at all. Its real decision point is the Resume picker, which passed the focused pane's provider to `listSessionsForCwd` -- so focusing an OpenCode pane and hitting Resume opened a picker guaranteed to be empty. It now falls back to a provider main can actually enumerate, rather than hiding Resume outright: the user's saved Claude sessions in that cwd are still there and still what they want. Duplicate Agent's `run` re-checked agent-hood while its `when` checked for a transcript adapter. `when` only controls the picker row; a keybinding or programmatic dispatch reaches `run` directly, so the weaker of the two is the one that decides. The new test drives each capability independently and asserts exactly one command turns on -- a transposition fails it even when, as here, it is invisible against the real matrix.
`close_agent` was refused 100% of the time in production.
Phase 8 replaced the tool's prose permission rule with a real grant store
in main: single-use, short-lived, checked at the bridge, default deny. The
mechanism is sound and its unit tests pass. Nothing ever called
`issueCloseGrant`. The only reference to it in the entire tree was its own
definition, so `consume` never found a grant, and every close request hit
the refusal path.
The tests could not see this because they tested the store, and the store
works. What was missing was the other half -- an issuer -- and an
authorization mechanism with no issuer is not strict, it is broken. It
shipped looking like a security improvement.
The layer was the mistake. A grant has to be issued by a USER ACTION, and
main has no user action that means "the user asked this agent to close that
agent". The renderer does: it has the user. So the check moves to the line
that actually mutates -- `closeSession`, which R2 just made the single gated
funnel -- via `requireConfirmation`, which overrides the idle-single
exemption. That exemption is about a human aiming at a pane they can see
with Undo Close behind them; none of it transfers to a close a model
decided to make.
The dialog names the requester ("Agent X is asking to close this agent"),
because a confirmation the user did not summon and cannot attribute is
unanswerable. `close_run` confirms once for the whole run rather than N
times, since twelve dialogs is how "are you sure?" stops meaning anything.
Deletes `src/mcp/shared/closeGrant.ts`, its tests, and the bridge methods.
The new tests drive the gate from a caller, which is what the old ones
could not do.
Five mechanisms that computed an answer nobody acted on.
`status('unavailable')` claimed, in its own docstring, to be "what lets the
picker grey a row instead of offering it as ordinary and executable". Only
the greying was real. Admission called `commandApplicable` directly and
never looked at the state, so a row rendered muted and labelled
"Unavailable" ran normally on Enter. The gateway now admits through
`resolveCommandAvailability`, which is the one place that honours both an
explicit `unavailableReason` and an unavailable status -- and it carries the
reason out in the outcome, so a caller can say why instead of clicking into
nothing.
The Settings keybinding editor hardcoded `context: 'global'` when checking
conflicts. 'global' overlaps everything, so binding a grid-only chord
reported a conflict with a dispatch-only command that can never be live at
the same time -- the exact distinction the context system and its disjoint
-pair matrix exist to draw, thrown away at the single call site that needed
it. It now reads the command's declared context.
Replace was offered whenever any owner was a command, even when a RESERVED
owner also held the chord. Accepting it stripped the other command's
binding and installed yours, and the chord still would not fire, because
the reserved owner still had it: a destructive edit that cannot achieve the
thing it was for. Reserved owners now suppress Replace entirely.
`CATEGORY_ORDER` was a hand-listed array that grouping filtered to, so a
command in a category nobody remembered to add would not appear in Settings
at all and therefore could not be rebound. Now `Record<CommandCategory, N>`,
which makes a new category a compile error until it has a position.
Ctrl+C, Ctrl+D and the readline editing keys were absent from the reserved
table. The agent pane is a terminal and `useKeybinds` bails when one owns
input, so a command bound there renders with a chord in Settings and never
fires in the place users spend their time.
DELETED, because they did nothing:
- Phase 2's target pinning (`resolveCommandTarget`,
`resolveCommandInvocation`, `targetStillValid`, `CommandDef.targetKind`).
Its own docstring admitted `run` has no target parameter, so the pinned id
was computed, carried, and ignored at the one moment it mattered. No
command ever set `targetKind`. It read as a safety mechanism while
providing none -- worse than absence, because the next reader would trust
it. The real fix for that class of bug landed in the close gate, which
re-reads live state around its own await instead of trusting a snapshot
taken at dispatch.
- `CommandState.truth` ('persisted' | 'runtime' | 'effective'), written by
two constructors onto every state and read by nothing. Its motivating
case -- Tail reporting on because only Tail All can turn it off -- is
carried by `detail`, which is actually rendered.
- `syntaxError` in the keybinding editor: set to null in three places, never
to a message, with a whole error panel behind it.
- `label: openClosed ? 'Mixed' : 'Mixed'`.
…not hold Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
They were scratch input for the remediation work, not deliverables. A `git add -A` swept them in; the exclude rule that was supposed to keep them out lived in the main checkout's info/exclude, and exclude rules have no effect on files that are already tracked. Kept out of the tree going forward via this worktree's own info/exclude. The reports themselves are preserved outside the repo. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # src/renderer/src/features/workspace/commands/dispatchColorFlagCommands.ts
Plan review checkpoint
This draft PR intentionally contains only the command-governance audit and implementation plan. No implementation has started.
The audit was produced with Agent Code Workflow MCP run
run_e219348d-cdc8-49f7-893b-23858601e68d: eight parallel research tracks plus an independent synthesis agent, followed by direct local verification.Highlights:
Validation at this checkpoint:
git diff --check: clean.Please review the Markdown plan before implementation begins.